前幾天我們去了動物園,那天太陽很大,曬得所有動物都受不了,它們都設法找一個陰影躲起來。我有一種說不清楚模糊的感覺,我也好希望跟這些動物一樣,有一些陰影可以躲起來 ... 我沒有水缸,沒有暗處,只有陽光,24小時從不間斷,明亮溫暖,陽光普照。
-- 電影《陽光普照》
特務 K 發現,儘管以太坊系統有眾多節點、有鉅額押金保護、驗算服務也有各種充滿創意的規模化專案,有那麼一小小小個點,讓他覺得不太對勁。
似乎 儀表板 打開一看,所有帳戶的餘額,往來的帳戶,交易紀錄清清楚楚。更有甚者,大家還會把帳戶綁個屬於自己的 .eth 域名。域名的確是幫助人們不用記憶冗長的十六進位地址,也避免各種作業失誤和駭客的誤導攻擊,卻也讓人們容易指認出哪些帳戶屬於誰。
特務 K 似乎碰觸到一個他不太熟悉、不太在乎,又常常在他工作上常需冒犯他人的議題:隱私 。
「的確是還有些更誇張的儀表板,彙整出某些知名人士,他們都持有什麼代幣,以及做了什麼交易」小雨說。
的確人們可能會想要錢很多的人,盡量可以揭露他們金錢的影響力和流向。目前最想要出力或投資隱私的也是這些不堪其擾的有錢人。
另外一種很想要隱私的人:壞人。 北韓駭客 已經透過攻擊各種交易所與合約漏洞,取得大量的資金,資助其彈道飛彈的設計,讓世界籠罩在核彈威脅之下。他們有隱藏其贓款的需求。
但隱私對一般使用者來說也極其重要。如果使用者在使用去中心化應用時,所有小小的金流流向都對所有網路中的陌生人一清二楚,那區塊鏈不僅沒有修正目前網路公司獨佔人們資料的霸權,反而成為下一個更可怕的數位監控怪物。
在區塊鏈的圈子人們有一句話,使用區塊鏈就像把你的銀行帳戶與交易放到推特上公開一樣。
有個理智健全的系統,應該讓公司需要能夠付薪水,薪水不被陌生人知道。去超商買杯咖啡,不會全世界都發現。
人們大多是從交易所,用現金換取第一筆幣的。但要在交易所換到幣,代表交易所已經透過 KYC 流程,取得人們的個資了。這套流程固有傳統金融打擊金融犯罪的目的,但同時交易所也會知道一個使用者,從交易所出去的代幣,最後都到了哪裡。這是除了網路上陌生人之外,一般使用者該有的隱私顧慮。
單純試圖把帳號的餘額轉到新的帳號是行不通的。轉到新的餘額,代表我們需要發一筆交易,這筆交易的資訊會把新帳戶與舊帳戶關聯起來,這樣分析者就知道新舊帳戶是一起的。
人們也試著把金額拆小,做好幾筆混淆交易,試圖混淆分析交易的人。但在幣流分析專家與工具如 chainalysis 的分析之下,混淆幾乎沒有任何意義。
混幣器目前是已知有效的擺脫帳戶關聯的做法。這邊關鍵的技術也是零知識證明。
這個思路是這樣: N 個人把幣打到某個合約,同樣的 N 個人再從合約領幣出來。只要沒辦法關聯存款者與提款者,要猜中正確的帳戶關聯就是 1/N 的機率。
「我們必須要談到一個最知名,最具爭議的專案」小雨說
2019 年開始的龍捲風現金(Tornado Cash),是以太坊上面最知名的專案。在這之前雖然有 Zcash 等應用零知識證明,改良比特幣,所做出來的區塊鏈專案。但龍捲風現金影響到的資金規模較大,引起的衝擊與對隱私的討論,以及後續效應,更值得直接討論。
「最主要,龍捲風現金的程式碼很短,還能順便觀摩一個夠有影響力的零知識證明專案怎麼運作」小雨說。「我們今天先講技術,後講爭議。」
龍捲風現金的設計是這樣:合約只有存款和提款兩個函式。
合約提供各種 匿名集(Anonymous Set),也就是同一種幣與同一種金額大小的存款者集合。合約支援以太幣和 ERC20 代幣。以太幣有一顆、十顆、百顆,三種餘額的匿名集可以選擇。強制用同一種餘額才不會因為金額不一樣而暴露存提款者之間的關係。
使用者把一筆金額,例如一顆以太幣,存入龍捲風現金合約。過了一段時間之後,也許幾個月,再從合約提款。
這裡很重要的是不能太快提款,否則會太容易因為時間關係被辨認出來。
這邊有個重要的點: 隱私不是一種功能,而是整個流程 。龍捲風只保障鏈上的隱私,但使用者需要在所有流程中小心不要外洩資訊。例如:舊帳戶和新帳戶未來又有其他的互動,這樣混幣器就是白混了。
「維安劇場(Security theater) 是一個資訊安全的詞,代表某個維護安全的步驟徒具戲劇效果,但失去其資安的實際作用」小雨說。
提款的時候,需要提交一個零知識證明,說明自己是合格的提款者。提款的證明程式,證明下面兩件事:
提款的時候,使用者也不會自己送交易去和合約互動。要透過第三方的中繼者(Relayer)去和合約互動。因為和合約互動需要用以太幣付燃氣手續費,而使用者用自己手上的帳戶去提款,有暴露存款者身份顧慮。
使用者可以把提款中的一小部分給中繼者當手續費。零知識證明可以順便當零知識數位簽章用,證明使用者願意給某個地址多少手續費。
想像我們有兩種雜湊函式,hash1 和 hash2 。使用者有個秘密的隨機值,這個隨機值必須夠大,例如: 128 位元,讓電腦無法暴力搜索,從雜湊值反推。
hash1(random)
hash2(random)
注意外人無法從雜湊值 hash1(random)
與 hash2(random)
得知其關聯。
因此使用者在提款時:
hash2(random)
不曾出現過,確認該使用者尚未提款。hash1(random)
,以公開參數接收 hash2(random)
。hash1(random)
與 hash2(random)
背後的隨機值是相同的。這樣驗證存提款是同一個人,卻又不揭露存款者的資訊。hash2(random)
記錄在鏈上。這樣重複提款時,在步驟一就會失敗。hash2(random)
像是在存款名單上,把提款過的人的名字劃掉,但劃掉了誰只有提款者知曉。
在 Github 上的 龍捲風現金 程式碼倉庫,曾經因為官司,被微軟預防性下架。在電子前哨基金會 EFF 的訴訟支援之下,還原程式碼倉庫,供人們研究其程式碼。
合約的核心是 Tornado.sol ,裡面就兩個函式:存款和提款。
Tornado 用的 hash1 與 hash2 如下:
Hash(nullifier + secret)
Hash(nullifier)
其中 Hash(nullifier + secret)
可以看作為 Hash
與 secret
(一般人們稱作「鹽」)組成的新雜湊函式,對參數 nullifier
作用。
註銷符(nullifier) 是一個隱私類零知識證明常見的詞,本質上就是個隨機數。
存款時,使用者會需要提交 hash1 。合約背後會建立出一棵雜湊樹,名曰存款樹。_insert
會把存款束縛,放入存款樹陣列中。
/**
@dev Deposit funds into the contract. The caller must send (for ETH) or approve (for ERC20) value equal to or `denomination` of this instance.
@param _commitment the note commitment, which is PedersenHash(nullifier + secret)
*/
function deposit(bytes32 _commitment) external payable nonReentrant {
require(!commitments[_commitment], "The commitment has been submitted");
uint32 insertedIndex = _insert(_commitment);
commitments[_commitment] = true;
_processDeposit();
emit Deposit(_commitment, insertedIndex, block.timestamp);
}
其他細節:
_processDeposit
是抽象出來的函式,原生以太幣與 ERC20 有不同的轉帳函式,會實作在其繼承的合約中。require
裡面有防呆,避免使用者提交兩次一樣的存款。emit Deposit
最後會釋放出一個日誌,這邊關鍵的是 insertedIndex
參數,日後要提款時,要製造出雜湊樹成員證明,必須知道自己的存款被放到陣列的第幾個位置。這必須是存款被區塊鏈把包之後才會知道的事,因為有可能很多人同時在存款,存款順序是區塊決定的。使用者的網頁會去取得所有的存款紀錄,並重構出存款樹。越新的存款樹,代表裡面裝載更多存款,也代表混幣的效果越好。
提款時要提交:零知識證明 _proof 、存款樹的樹根 _root 、提款的 hash2 、提款金額的收受人 _recipient
、中繼人小費收受者 _relayer
、小費金額 _fee
本身。_refund
是 ERC20 合約的小細節,我們先不理會。
合約主要檢驗:
/**
@dev Withdraw a deposit from the contract. `proof` is a zkSNARK proof data, and input is an array of circuit public inputs
`input` array consists of:
- merkle root of all deposits in the contract
- hash of unique deposit nullifier to prevent double spends
- the recipient of funds
- optional fee that goes to the transaction sender (usually a relay)
*/
function withdraw(
bytes calldata _proof,
bytes32 _root,
bytes32 _nullifierHash,
address payable _recipient,
address payable _relayer,
uint256 _fee,
uint256 _refund
) external payable nonReentrant {
require(_fee <= denomination, "Fee exceeds transfer value");
require(!nullifierHashes[_nullifierHash], "The note has been already spent");
require(isKnownRoot(_root), "Cannot find your merkle root"); // Make sure to use a recent one
require(
verifier.verifyProof(
_proof,
[uint256(_root), uint256(_nullifierHash), uint256(_recipient), uint256(_relayer), _fee, _refund]
),
"Invalid withdraw proof"
);
nullifierHashes[_nullifierHash] = true;
_processWithdraw(_recipient, _relayer, _fee, _refund);
emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee);
}
其他細節:
除了必要的雜湊函式、數值位元轉換、雜湊樹之外,龍捲風現金加上註解居然不到 100 行程式碼。
重點:
CommitmentHasher
計算必要的雜湊值: hash1 和 hash2 。
hasher.nullifierHash === nullifierHash;
這行檢查與使用者輸入值相同。hasher.commitment
的方式,做雜湊樹成員檢查,確認有包含在存款樹根為 root
的樹中。include "../node_modules/circomlib/circuits/bitify.circom";
include "../node_modules/circomlib/circuits/pedersen.circom";
include "merkleTree.circom";
// computes Pedersen(nullifier + secret)
template CommitmentHasher() {
signal input nullifier;
signal input secret;
signal output commitment;
signal output nullifierHash;
component commitmentHasher = Pedersen(496);
component nullifierHasher = Pedersen(248);
component nullifierBits = Num2Bits(248);
component secretBits = Num2Bits(248);
nullifierBits.in <== nullifier;
secretBits.in <== secret;
for (var i = 0; i < 248; i++) {
nullifierHasher.in[i] <== nullifierBits.out[i];
commitmentHasher.in[i] <== nullifierBits.out[i];
commitmentHasher.in[i + 248] <== secretBits.out[i];
}
commitment <== commitmentHasher.out[0];
nullifierHash <== nullifierHasher.out[0];
}
// Verifies that commitment that corresponds to given secret and nullifier is included in the merkle tree of deposits
template Withdraw(levels) {
signal input root;
signal input nullifierHash;
signal input recipient; // not taking part in any computations
signal input relayer; // not taking part in any computations
signal input fee; // not taking part in any computations
signal input refund; // not taking part in any computations
signal private input nullifier;
signal private input secret;
signal private input pathElements[levels];
signal private input pathIndices[levels];
component hasher = CommitmentHasher();
hasher.nullifier <== nullifier;
hasher.secret <== secret;
hasher.nullifierHash === nullifierHash;
component tree = MerkleTreeChecker(levels);
tree.leaf <== hasher.commitment;
tree.root <== root;
for (var i = 0; i < levels; i++) {
tree.pathElements[i] <== pathElements[i];
tree.pathIndices[i] <== pathIndices[i];
}
// Add hidden signals to make sure that tampering with recipient or fee will invalidate the snark proof
// Most likely it is not required, but it's better to stay on the safe side and it only takes 2 constraints
// Squares are used to prevent optimizer from removing those constraints
signal recipientSquare;
signal feeSquare;
signal relayerSquare;
signal refundSquare;
recipientSquare <== recipient * recipient;
feeSquare <== fee * fee;
relayerSquare <== relayer * relayer;
refundSquare <== refund * refund;
}
component main = Withdraw(20);
其他細節:
有趣的是 recipient
fee
relayer
refund
這幾個參數。迴路最後做了一些沒有用途的運算。目的是為了讓這些變數留在迴路裡面,讓限制式發揮作用。這會卡住他們在合約中的值,相當於變相用零知識證明對這些值做數位簽章。
零知識證明迴路需要滿足兩種性質:
對於隱私類的應用,額外要求:
因此對應以上性質,零知識證明迴路特有的程式錯誤有三類:
hasher.nullifierHash === nullifierHash;
這行限制式。迴路便不會檢查,迴路內算出來的雜湊值與公開輸入的雜湊值。壞人可以在公開輸入放任意的雜湊值,供合約記錄。這樣壞人每次用不同的公開雜湊值,可以一次存款,多次提款,掏空合約的錢。nullifier
與 secret
變數設為公開輸入,這樣提款者的私密資訊曝光,任何人可以用這兩樣資訊推導出提款者身份。總體來說,疏忽限制是最危險的,這讓壞人可以做壞事,而且因為零知識證明的性質,通常沒辦法知道系統已經有漏洞、漏洞正在被利用、抓出壞人是誰、或做災害還原。在隱私類應用中,暴露資訊是第二嚴重的,通常會造成使用者的一些傷害。過度限制則稍微無害一點,儘管是讓系統動彈不得,但通常傷害可知,也知道怎麼復原。